/*
 * Copyright (c) 2019-2024 GeyserMC. http://geysermc.org
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 *
 * @author GeyserMC
 * @link https://github.com/GeyserMC/Geyser
 */

package org.geysermc.geyser.session;

import com.google.gson.JsonObject;
import io.netty.channel.Channel;
import io.netty.channel.EventLoop;
import it.unimi.dsi.fastutil.Pair;
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import it.unimi.dsi.fastutil.objects.Object2IntMap;
import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.text.Component;
import net.raphimc.minecraftauth.java.JavaAuthManager;
import net.raphimc.minecraftauth.java.exception.MinecraftProfileNotFoundException;
import net.raphimc.minecraftauth.java.model.MinecraftProfile;
import net.raphimc.minecraftauth.java.model.MinecraftToken;
import net.raphimc.minecraftauth.util.MinecraftAuth4To5Migrator;
import org.checkerframework.checker.index.qual.NonNegative;
import org.checkerframework.checker.index.qual.Positive;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.checkerframework.common.value.qual.IntRange;
import org.cloudburstmc.math.vector.Vector2f;
import org.cloudburstmc.math.vector.Vector2i;
import org.cloudburstmc.math.vector.Vector3f;
import org.cloudburstmc.math.vector.Vector3i;
import org.cloudburstmc.nbt.NbtMap;
import org.cloudburstmc.netty.channel.raknet.RakChildChannel;
import org.cloudburstmc.netty.handler.codec.raknet.common.RakSessionCodec;
import org.cloudburstmc.protocol.bedrock.BedrockDisconnectReasons;
import org.cloudburstmc.protocol.bedrock.BedrockServerSession;
import org.cloudburstmc.protocol.bedrock.data.Ability;
import org.cloudburstmc.protocol.bedrock.data.AbilityLayer;
import org.cloudburstmc.protocol.bedrock.data.AuthoritativeMovementMode;
import org.cloudburstmc.protocol.bedrock.data.ChatRestrictionLevel;
import org.cloudburstmc.protocol.bedrock.data.ExperimentData;
import org.cloudburstmc.protocol.bedrock.data.GamePublishSetting;
import org.cloudburstmc.protocol.bedrock.data.GameRuleData;
import org.cloudburstmc.protocol.bedrock.data.GameType;
import org.cloudburstmc.protocol.bedrock.data.LevelEvent;
import org.cloudburstmc.protocol.bedrock.data.PlayerPermission;
import org.cloudburstmc.protocol.bedrock.data.SoundEvent;
import org.cloudburstmc.protocol.bedrock.data.SpawnBiomeType;
import org.cloudburstmc.protocol.bedrock.data.command.CommandEnumData;
import org.cloudburstmc.protocol.bedrock.data.command.CommandPermission;
import org.cloudburstmc.protocol.bedrock.data.command.SoftEnumUpdateType;
import org.cloudburstmc.protocol.bedrock.data.definitions.DimensionDefinition;
import org.cloudburstmc.protocol.bedrock.data.entity.EntityFlag;
import org.cloudburstmc.protocol.bedrock.data.inventory.ItemData;
import org.cloudburstmc.protocol.bedrock.data.inventory.crafting.recipe.CraftingRecipeData;
import org.cloudburstmc.protocol.bedrock.packet.AvailableEntityIdentifiersPacket;
import org.cloudburstmc.protocol.bedrock.packet.BedrockPacket;
import org.cloudburstmc.protocol.bedrock.packet.BiomeDefinitionListPacket;
import org.cloudburstmc.protocol.bedrock.packet.CameraPresetsPacket;
import org.cloudburstmc.protocol.bedrock.packet.ChunkRadiusUpdatedPacket;
import org.cloudburstmc.protocol.bedrock.packet.CreativeContentPacket;
import org.cloudburstmc.protocol.bedrock.packet.DimensionDataPacket;
import org.cloudburstmc.protocol.bedrock.packet.EmoteListPacket;
import org.cloudburstmc.protocol.bedrock.packet.GameRulesChangedPacket;
import org.cloudburstmc.protocol.bedrock.packet.ItemComponentPacket;
import org.cloudburstmc.protocol.bedrock.packet.LevelEventPacket;
import org.cloudburstmc.protocol.bedrock.packet.LevelSoundEventPacket;
import org.cloudburstmc.protocol.bedrock.packet.PlayStatusPacket;
import org.cloudburstmc.protocol.bedrock.packet.SetCommandsEnabledPacket;
import org.cloudburstmc.protocol.bedrock.packet.SetEntityMotionPacket;
import org.cloudburstmc.protocol.bedrock.packet.SetTimePacket;
import org.cloudburstmc.protocol.bedrock.packet.StartGamePacket;
import org.cloudburstmc.protocol.bedrock.packet.SyncEntityPropertyPacket;
import org.cloudburstmc.protocol.bedrock.packet.TextPacket;
import org.cloudburstmc.protocol.bedrock.packet.TransferPacket;
import org.cloudburstmc.protocol.bedrock.packet.UpdateAbilitiesPacket;
import org.cloudburstmc.protocol.bedrock.packet.UpdateAdventureSettingsPacket;
import org.cloudburstmc.protocol.bedrock.packet.UpdateAttributesPacket;
import org.cloudburstmc.protocol.bedrock.packet.UpdateClientInputLocksPacket;
import org.cloudburstmc.protocol.bedrock.packet.UpdateSoftEnumPacket;
import org.cloudburstmc.protocol.common.util.OptionalBoolean;
import org.geysermc.api.util.BedrockPlatform;
import org.geysermc.api.util.InputMode;
import org.geysermc.api.util.UiProfile;
import org.geysermc.cumulus.form.Form;
import org.geysermc.cumulus.form.util.FormBuilder;
import org.geysermc.geyser.GeyserImpl;
import org.geysermc.geyser.api.bedrock.camera.CameraData;
import org.geysermc.geyser.api.bedrock.camera.CameraShake;
import org.geysermc.geyser.api.connection.GeyserConnection;
import org.geysermc.geyser.api.entity.EntityData;
import org.geysermc.geyser.api.entity.type.GeyserEntity;
import org.geysermc.geyser.api.entity.type.player.GeyserPlayerEntity;
import org.geysermc.geyser.api.event.bedrock.SessionDisconnectEvent;
import org.geysermc.geyser.api.event.bedrock.SessionLoginEvent;
import org.geysermc.geyser.api.network.RemoteServer;
import org.geysermc.geyser.api.skin.SkinData;
import org.geysermc.geyser.api.util.PlatformType;
import org.geysermc.geyser.command.CommandRegistry;
import org.geysermc.geyser.command.GeyserCommandSource;
import org.geysermc.geyser.configuration.GeyserConfig;
import org.geysermc.geyser.entity.EntityDefinitions;
import org.geysermc.geyser.entity.GeyserEntityData;
import org.geysermc.geyser.entity.attribute.GeyserAttributeType;
import org.geysermc.geyser.entity.type.BoatEntity;
import org.geysermc.geyser.entity.type.Entity;
import org.geysermc.geyser.entity.type.ItemFrameEntity;
import org.geysermc.geyser.entity.type.Tickable;
import org.geysermc.geyser.entity.type.player.PlayerEntity;
import org.geysermc.geyser.entity.type.player.SessionPlayerEntity;
import org.geysermc.geyser.entity.vehicle.ClientVehicle;
import org.geysermc.geyser.erosion.AbstractGeyserboundPacketHandler;
import org.geysermc.geyser.erosion.ErosionCancellationException;
import org.geysermc.geyser.erosion.GeyserboundHandshakePacketHandler;
import org.geysermc.geyser.event.type.SessionDisconnectEventImpl;
import org.geysermc.geyser.impl.camera.CameraDefinitions;
import org.geysermc.geyser.impl.camera.GeyserCameraData;
import org.geysermc.geyser.input.InputLocksFlag;
import org.geysermc.geyser.inventory.Inventory;
import org.geysermc.geyser.inventory.InventoryHolder;
import org.geysermc.geyser.inventory.LecternContainer;
import org.geysermc.geyser.inventory.PlayerInventory;
import org.geysermc.geyser.inventory.recipe.GeyserRecipe;
import org.geysermc.geyser.inventory.recipe.GeyserSmithingRecipe;
import org.geysermc.geyser.inventory.recipe.GeyserStonecutterData;
import org.geysermc.geyser.item.Items;
import org.geysermc.geyser.item.type.BlockItem;
import org.geysermc.geyser.level.BedrockDimension;
import org.geysermc.geyser.level.JavaDimension;
import org.geysermc.geyser.level.physics.CollisionManager;
import org.geysermc.geyser.network.GameProtocol;
import org.geysermc.geyser.network.netty.LocalSession;
import org.geysermc.geyser.registry.Registries;
import org.geysermc.geyser.registry.type.BlockMappings;
import org.geysermc.geyser.registry.type.ItemMappings;
import org.geysermc.geyser.session.auth.AuthData;
import org.geysermc.geyser.session.auth.BedrockClientData;
import org.geysermc.geyser.session.cache.AdvancementsCache;
import org.geysermc.geyser.session.cache.BlockBreakHandler;
import org.geysermc.geyser.session.cache.BookEditCache;
import org.geysermc.geyser.session.cache.BundleCache;
import org.geysermc.geyser.session.cache.ChunkCache;
import org.geysermc.geyser.session.cache.EntityCache;
import org.geysermc.geyser.session.cache.EntityEffectCache;
import org.geysermc.geyser.session.cache.FormCache;
import org.geysermc.geyser.session.cache.InputCache;
import org.geysermc.geyser.session.cache.LodestoneCache;
import org.geysermc.geyser.session.cache.PistonCache;
import org.geysermc.geyser.session.cache.PreferencesCache;
import org.geysermc.geyser.session.cache.RegistryCache;
import org.geysermc.geyser.session.cache.SkullCache;
import org.geysermc.geyser.session.cache.StructureBlockCache;
import org.geysermc.geyser.session.cache.TagCache;
import org.geysermc.geyser.session.cache.TeleportCache;
import org.geysermc.geyser.session.cache.WorldBorder;
import org.geysermc.geyser.session.cache.WorldCache;
import org.geysermc.geyser.session.cache.registry.JavaRegistries;
import org.geysermc.geyser.session.cache.tags.DialogTag;
import org.geysermc.geyser.session.cache.waypoint.WaypointCache;
import org.geysermc.geyser.session.dialog.BuiltInDialog;
import org.geysermc.geyser.session.dialog.Dialog;
import org.geysermc.geyser.session.dialog.DialogManager;
import org.geysermc.geyser.skin.SkinManager;
import org.geysermc.geyser.text.GeyserLocale;
import org.geysermc.geyser.translator.inventory.InventoryTranslator;
import org.geysermc.geyser.translator.text.MessageTranslator;
import org.geysermc.geyser.util.ChunkUtils;
import org.geysermc.geyser.util.EntityUtils;
import org.geysermc.geyser.util.InventoryUtils;
import org.geysermc.geyser.util.LoginEncryptionUtils;
import org.geysermc.geyser.util.MathUtils;
import org.geysermc.mcprotocollib.auth.GameProfile;
import org.geysermc.mcprotocollib.network.BuiltinFlags;
import org.geysermc.mcprotocollib.network.ClientSession;
import org.geysermc.mcprotocollib.network.packet.Packet;
import org.geysermc.mcprotocollib.network.session.ClientNetworkSession;
import org.geysermc.mcprotocollib.protocol.ClientListener;
import org.geysermc.mcprotocollib.protocol.MinecraftConstants;
import org.geysermc.mcprotocollib.protocol.MinecraftProtocol;
import org.geysermc.mcprotocollib.protocol.data.ProtocolState;
import org.geysermc.mcprotocollib.protocol.data.game.ServerLink;
import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.Pose;
import org.geysermc.mcprotocollib.protocol.data.game.entity.object.Direction;
import org.geysermc.mcprotocollib.protocol.data.game.entity.player.GameMode;
import org.geysermc.mcprotocollib.protocol.data.game.entity.player.Hand;
import org.geysermc.mcprotocollib.protocol.data.game.entity.player.HandPreference;
import org.geysermc.mcprotocollib.protocol.data.game.entity.player.PlayerAction;
import org.geysermc.mcprotocollib.protocol.data.game.entity.player.ResolvableProfile;
import org.geysermc.mcprotocollib.protocol.data.game.setting.ChatVisibility;
import org.geysermc.mcprotocollib.protocol.data.game.setting.ParticleStatus;
import org.geysermc.mcprotocollib.protocol.data.game.setting.SkinPart;
import org.geysermc.mcprotocollib.protocol.data.game.statistic.CustomStatistic;
import org.geysermc.mcprotocollib.protocol.data.game.statistic.Statistic;
import org.geysermc.mcprotocollib.protocol.data.handshake.HandshakeIntent;
import org.geysermc.mcprotocollib.protocol.packet.common.serverbound.ServerboundClientInformationPacket;
import org.geysermc.mcprotocollib.protocol.packet.configuration.serverbound.ServerboundAcceptCodeOfConductPacket;
import org.geysermc.mcprotocollib.protocol.packet.ingame.serverbound.ServerboundChatCommandSignedPacket;
import org.geysermc.mcprotocollib.protocol.packet.ingame.serverbound.ServerboundChatPacket;
import org.geysermc.mcprotocollib.protocol.packet.ingame.serverbound.player.ServerboundPlayerAbilitiesPacket;
import org.geysermc.mcprotocollib.protocol.packet.ingame.serverbound.player.ServerboundPlayerActionPacket;
import org.geysermc.mcprotocollib.protocol.packet.ingame.serverbound.player.ServerboundUseItemPacket;
import org.geysermc.mcprotocollib.protocol.packet.login.serverbound.ServerboundCustomQueryAnswerPacket;

import java.net.InetSocketAddress;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.BitSet;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Queue;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

@Getter
public class GeyserSession implements GeyserConnection, GeyserCommandSource {

    private final GeyserImpl geyser;
    private final UpstreamSession upstream;
    private DownstreamSession downstream;
    /**
     * The loop where all packets and ticking is processed to prevent concurrency issues.
     * If this is manually called, ensure that any exceptions are properly handled.
     */
    private final EventLoop tickEventLoop;
    @Setter
    private AuthData authData;
    private BedrockClientData clientData;
    /**
     * Used for Floodgate skin uploading
     */
    @Setter
    private List<String> certChainData;
    @Setter
    private String token;

    @NonNull
    @Setter
    private volatile AbstractGeyserboundPacketHandler erosionHandler;

    @Accessors(fluent = true)
    @Setter
    private RemoteServer remoteServer;

    private final SessionPlayerEntity playerEntity;

    private final AdvancementsCache advancementsCache;
    private final BookEditCache bookEditCache;
    private final BundleCache bundleCache;
    private final ChunkCache chunkCache;
    private final EntityCache entityCache;
    private final EntityEffectCache effectCache;
    private final FormCache formCache;
    private final InputCache inputCache;
    private final LodestoneCache lodestoneCache;
    private final PistonCache pistonCache;
    private final PreferencesCache preferencesCache;
    private final RegistryCache registryCache;
    private final SkullCache skullCache;
    private final StructureBlockCache structureBlockCache;
    private final TagCache tagCache;
    private final WaypointCache waypointCache;
    private final WorldCache worldCache;

    /**
     * Handles block breaking and break animation progress caching.
     */
    @Setter
    private BlockBreakHandler blockBreakHandler;

    @Setter
    private TeleportCache unconfirmedTeleport;

    private final WorldBorder worldBorder;
    /**
     * Whether simulated fog has been sent to the client or not.
     */
    private boolean isInWorldBorderWarningArea = false;

    /**
     * Stores the player inventory and player inventory translator
     */
    private final InventoryHolder<PlayerInventory> playerInventoryHolder;

    /**
     * Stores the current open Bedrock inventory, including the correct translator.
     * Prefer using {@link InventoryUtils#getInventory(GeyserSession, int)}, as this
     * method can e.g. return a {@code InventoryHolder<LecternContainer>} due to the
     * workaround in {@link LecternContainer#isBookInPlayerInventory()} workaround.
     */
    @Setter
    private @Nullable InventoryHolder<? extends Inventory> inventoryHolder;

    private final DialogManager dialogManager = new DialogManager(this);

    /**
     * A list of links sent to us by the server in the server links packet.
     */
    @Setter
    private List<ServerLink> serverLinks = List.of();

    /**
     * A list of commands known to the client. These are all the commands that have been sent to us by the server.
     */
    @Setter
    private List<String> knownCommands = List.of();
    /**
     * A list of "restricted" commands known to the client. These are all the commands that have been sent to us by the server, and require some sort of elevated permissions.
     */
    @Setter
    private List<String> restrictedCommands = List.of();

    /**
     * Whether the client is currently closing an inventory.
     * Used to open new inventories while another one is currently open.
     */
    @Setter
    private boolean closingInventory;

    /**
     * Stores the bedrock inventory id of the pending inventory, or -1 if no inventory is pending.
     * This id is only set when the block that should be opened exists.
     */
    @Setter
    private int pendingOrCurrentBedrockInventoryId = -1;

    /**
     * Use {@link #getNextItemNetId()} instead for consistency
     */
    @Getter(AccessLevel.NONE)
    private final AtomicInteger itemNetId = new AtomicInteger(2);

    @Setter
    private ScheduledFuture<?> containerOutputFuture;

    /**
     * Stores session collision
     */
    private final CollisionManager collisionManager;

    /**
     * Stores the block mappings for this specific version.
     */
    @Setter
    private BlockMappings blockMappings;

    /**
     * Stores the item translations for this specific version.
     */
    @Setter
    private ItemMappings itemMappings;

    /**
     * A map of Vector3i positions to Java entities.
     * Used for translating Bedrock block actions to Java entity actions.
     */
    private final Map<Vector3i, ItemFrameEntity> itemFrameCache = new Object2ObjectOpenHashMap<>();

    /**
     * A map of all players (and their heads) that are wearing a player head with a custom texture.
     * Our workaround for these players is to give them a custom skin and geometry to emulate wearing a custom skull.
     */
    private final Map<UUID, ResolvableProfile> playerWithCustomHeads = new Object2ObjectOpenHashMap<>();

    @Setter
    private boolean droppingLecternBook;

    @Setter
    private Vector2i lastChunkPosition = null;
    private int clientRenderDistance = -1;
    private int serverRenderDistance = -1;

    // Exposed for GeyserConnect usage
    protected boolean sentSpawnPacket;

    boolean loggedIn;
    boolean loggingIn;

    @Setter
    private boolean spawned;
    /**
     * Accessed on the initial Java and Bedrock packet processing threads
     */
    private volatile boolean closed;

    private GameMode gameMode = GameMode.SURVIVAL;

    /**
     * Keeps track of the world name for respawning.
     */
    @Setter
    private Key worldName = null;
    /**
     * As of Java 1.19.3, the client only uses these for commands.
     */
    @Setter
    private String[] levels;

    private boolean sneaking;

    /**
     * Used to send a shift state for a tick to dismount from entitites
     */
    @Setter
    private boolean shouldSendSneak;

    /**
     * Stores the Java pose that the server and/or Geyser believes the player currently has.
     */
    @Setter
    private Pose pose = Pose.STANDING;

    /**
     * This is used to keep track of player sprinting and should only change by START_SPRINT and STOP_SPRINT sent by the player, not from flag update.
     */
    @Setter
    private boolean sprinting;

    /**
     * The overworld dimension which Bedrock Edition uses.
     */
    private BedrockDimension bedrockOverworldDimension = BedrockDimension.OVERWORLD;
    /**
     * The dimension of the player.
     * As all entities are in the same world, this can be safely applied to all other entities.
     */
    @MonotonicNonNull
    @Setter
    private JavaDimension dimensionType = null;
    /**
     * Which dimension Bedrock understands themselves to be in.
     * This should only be set after the ChangeDimensionPacket is sent, or
     * right before the StartGamePacket is sent.
     */
    @Setter
    private BedrockDimension bedrockDimension = this.bedrockOverworldDimension;

    @Setter
    private Vector3i lastBlockPlacePosition;

    @Setter
    private BlockItem lastBlockPlaced;

    @Setter
    private boolean interacting;

    /**
     * Stores the last position of the block the player interacted with. This can either be a block that the client
     * placed or an existing block the player interacted with (for example, a chest). <br>
     * Initialized as (0, 0, 0) so it is always not-null.
     */
    @Setter
    private Vector3i lastInteractionBlockPosition = Vector3i.ZERO;

    /**
     * Stores the position of the player the last time they interacted.
     * Used to verify that the player did not move since their last interaction. <br>
     * Initialized as (0, 0, 0) so it is always not-null.
     */
    @Setter
    private Vector3f lastInteractionPlayerPosition = Vector3f.ZERO;

    /**
     * The entity that the client is currently looking at.
     */
    @Setter
    private Entity mouseoverEntity;

    /**
     * Stores all Java recipes by ID, and matches them to all possible Bedrock recipe identifiers.
     */
    private final Int2ObjectMap<List<String>> javaToBedrockRecipeIds;

    private final Int2ObjectMap<GeyserRecipe> craftingRecipes;
    @Setter
    private Pair<CraftingRecipeData, GeyserRecipe> lastCreatedRecipe = null; // TODO try to prevent sending duplicate recipes
    private final AtomicInteger lastRecipeNetId;

    /**
     * Saves a list of all stonecutter recipes, for use in a stonecutter inventory.
     * The key is the Bedrock recipe net ID; the values are their respective output and button ID.
     */
    @Setter
    private Int2ObjectMap<GeyserStonecutterData> stonecutterRecipes;
    private final List<GeyserSmithingRecipe> smithingRecipes = new ArrayList<>();

    /**
     * Whether to work around 1.13's different behavior in villager trading menus.
     */
    @Setter
    private boolean emulatePost1_13Logic = true;
    /**
     * Starting in 1.17, Java servers expect the <code>carriedItem</code> parameter of the serverbound click container
     * packet to be the current contents of the mouse after the transaction has been done. 1.16 expects the clicked slot
     * contents before any transaction is done. With the current ViaVersion structure, if we do not send what 1.16 expects
     * and send multiple click container packets, then successive transactions will be rejected.
     */
    @Setter
    private boolean emulatePost1_16Logic = true;
    @Setter
    private boolean emulatePost1_18Logic = true;

    /**
     * Whether to emulate pre-1.20 smithing table behavior.
     * Adapts ViaVersion's furnace UI to one Bedrock can use.
     * See {@link org.geysermc.geyser.translator.inventory.OldSmithingTableTranslator}.
     */
    @Setter
    private boolean oldSmithingTable = false;

    /**
     * Whether to use the minecart_improvements experiment
     */
    @Setter
    private boolean isUsingExperimentalMinecartLogic = false;

    /**
     * The current attack speed of the player. Used for sending proper cooldown timings.
     * Setting a default fixes cooldowns not showing up on a fresh world.
     */
    @Setter
    private double attackSpeed = 4.0d;
    /**
     * The time of the last hit. Used to gauge how long the cooldown is taking.
     * This is a session variable in order to prevent more scheduled threads than necessary.
     */
    @Setter
    private long lastHitTime;

    /**
     * Saves if the client is steering left on a boat.
     */
    @Setter
    private boolean steeringLeft;
    /**
     * Saves if the client is steering right on a boat.
     */
    @Setter
    private boolean steeringRight;

    /**
     * Store the last time the player interacted. Used to fix a right-click spam bug.
     * See <a href="https://github.com/GeyserMC/Geyser/issues/503">this</a> for context.
     */
    @Setter
    private long lastInteractionTime;

    /**
     * Stores whether the player intended to place a bucket.
     */
    @Setter
    private boolean placedBucket;

    /**
     * Counts how many ticks have occurred since an arm animation started.
     * -1 means there is no active arm swing
     */
    private int armAnimationTicks = -1;

    /**
     * The tick in which the player last hit air.
     * Used to ensure we dont send two sing packets for one hit.
     */
    @Setter
    private int lastAirHitTick;

    /**
     * Keep track of fireworks rockets that are attached to the player.
     * Used to keep track of attached fireworks rocket and improve fireworks rocket boosting parity.
     */
    private final List<Long> attachedFireworkRockets = new CopyOnWriteArrayList<>();

    /**
     * Controls whether the daylight cycle gamerule has been sent to the client, so the sun/moon remain motionless.
     */
    private boolean daylightCycle = true;

    private boolean reducedDebugInfo = false;

    /**
     * The op permission level set by the server
     */
    @Setter
    private int opPermissionLevel = 0;

    /**
     * If the current player can fly
     */
    @Setter
    private boolean canFly = false;

    /**
     * If the current player is flying
     */
    @Setter
    private boolean flying = false;

    /**
     * If the current player should be able to noclip through blocks, this is used for void floor workaround and not spectator.
     */
    private boolean noClip = false;

    @Setter
    private boolean instabuild = false;

    @Setter
    private float flySpeed;
    @Setter
    private float walkSpeed;

    /**
     * Caches current rain strength.
     * Value between 0 and 1.
     */
    private float rainStrength = 0.0f;

    /**
     * Caches current thunder strength.
     * Value between 0 and 1.
     */
    private float thunderStrength = 0.0f;

    /**
     * Stores a map of all statistics sent from the server.
     * The server only sends new statistics back to us, so in order to show all statistics we need to cache existing ones.
     */
    private final Object2IntMap<Statistic> statistics = new Object2IntOpenHashMap<>(0);

    /**
     * Whether we're expecting statistics to be sent back to us.
     */
    @Setter
    private boolean waitingForStatistics = false;

    private final Set<UUID> emotes;

    /**
     * Whether advanced tooltips will be added to the player's items.
     */
    @Setter
    private boolean advancedTooltips = false;

    /**
     * The thread that will run every game tick.
     */
    private ScheduledFuture<?> tickThread = null;

    /**
     * The number of ticks that have elapsed since the start of this session
     */
    private int ticks;

    /**
     * The number of ticks that have elapsed since the start of this session according to the client.
     */
    @Setter
    private long clientTicks;

    /**
     * The world time in ticks according to the server
     * <p>
     * Note: The TickingStatePacket is currently ignored.
     */
    @Setter
    private long worldTicks;

    /**
     * Used to return players back to their vehicles if the server doesn't want them unmounting.
     */
    @Setter
    private ScheduledFuture<?> mountVehicleScheduledFuture = null;

    /**
     * A cache of IDs from ClientboundKeepAlivePackets that have been sent to the Bedrock client, but haven't been returned to the server.
     * Only used if {@link GeyserConfig.GameplayConfig#forwardPlayerPing()} is enabled.
     */
    private final Queue<Long> keepAliveCache = new ConcurrentLinkedQueue<>();

    /**
     * Stores the book that is currently being read. Used in {@link org.geysermc.geyser.translator.protocol.java.inventory.JavaOpenBookTranslator}
     */
    @Setter
    private @Nullable ItemData currentBook = null;

    /**
     * Stores cookies sent by the Java server.
     */
    @Setter
    private Map<String, byte[]> cookies = new Object2ObjectOpenHashMap<>();

    private final GeyserCameraData cameraData;

    private final GeyserEntityData entityData;

    @Getter(AccessLevel.MODULE)
    private MinecraftProtocol protocol;

    private int nanosecondsPerTick = 50000000;
    private float millisecondsPerTick = 50.0f;
    private boolean tickingFrozen = false;
    /**
     * The amount of ticks requested by the server that the game should proceed with, even if the game tick loop is frozen.
     */
    @Setter
    private int stepTicks = 0;

    @Setter
    private boolean allowVibrantVisuals = true;

    @Accessors(fluent = true)
    private boolean hasAcceptedCodeOfConduct = false;

    @Accessors(fluent = true)
    @Setter
    private boolean integratedPackActive = false;

    private final Set<InputLocksFlag> inputLocksSet = EnumSet.noneOf(InputLocksFlag.class);
    private boolean inputLockDirty;

    public GeyserSession(GeyserImpl geyser, BedrockServerSession bedrockServerSession, EventLoop tickEventLoop) {
        this.geyser = geyser;
        this.upstream = new UpstreamSession(bedrockServerSession);
        this.tickEventLoop = tickEventLoop;

        this.erosionHandler = new GeyserboundHandshakePacketHandler(this);

        this.advancementsCache = new AdvancementsCache(this);
        this.bookEditCache = new BookEditCache(this);
        this.bundleCache = new BundleCache(this);
        this.chunkCache = new ChunkCache(this);
        this.entityCache = new EntityCache(this);
        this.effectCache = new EntityEffectCache();
        this.formCache = new FormCache(this);
        this.inputCache = new InputCache(this);
        this.lodestoneCache = new LodestoneCache();
        this.pistonCache = new PistonCache(this);
        this.preferencesCache = new PreferencesCache(this);
        this.registryCache = new RegistryCache(this);
        this.skullCache = new SkullCache(this);
        this.structureBlockCache = new StructureBlockCache();
        this.tagCache = new TagCache(this);
        this.waypointCache = new WaypointCache(this);
        this.worldCache = new WorldCache(this);
        this.cameraData = new GeyserCameraData(this);
        this.entityData = new GeyserEntityData(this);

        this.worldBorder = new WorldBorder(this);
        this.collisionManager = new CollisionManager(this);
        this.blockBreakHandler = new BlockBreakHandler(this);

        this.playerEntity = new SessionPlayerEntity(this);
        collisionManager.updatePlayerBoundingBox(this.playerEntity.getPosition());

        this.playerInventoryHolder = new InventoryHolder<>(this, new PlayerInventory(this), InventoryTranslator.PLAYER_INVENTORY_TRANSLATOR);
        this.inventoryHolder = null;
        this.craftingRecipes = new Int2ObjectOpenHashMap<>();
        this.javaToBedrockRecipeIds = new Int2ObjectOpenHashMap<>();
        this.lastRecipeNetId = new AtomicInteger(InventoryUtils.LAST_RECIPE_NET_ID + 1);

        this.spawned = false;
        this.loggedIn = false;

        this.emotes = new HashSet<>();
        geyser.getSessionManager().getSessions().values().forEach(player -> this.emotes.addAll(player.getEmotes()));

        this.remoteServer = geyser.defaultRemoteServer();
    }

    /**
     * Send all necessary packets to load Bedrock into the server
     */
    public void connect() {
        // Note: this.dimensionType may be null here if the player is connecting from online mode
        int minY = BedrockDimension.OVERWORLD.minY();
        int maxY = BedrockDimension.OVERWORLD.maxY();
        for (JavaDimension javaDimension : this.registryCache.registry(JavaRegistries.DIMENSION_TYPE).values()) {
            if (javaDimension.bedrockId() == BedrockDimension.OVERWORLD_ID) {
                minY = Math.min(minY, javaDimension.minY());
                maxY = Math.max(maxY, javaDimension.minY() + javaDimension.height());
            }
        }
        minY = Math.max(minY, -512);
        maxY = Math.min(maxY, 512);

        if (minY < BedrockDimension.OVERWORLD.minY() || maxY > BedrockDimension.OVERWORLD.maxY()) {
            final boolean isInOverworld = this.bedrockDimension == this.bedrockOverworldDimension;
            this.bedrockOverworldDimension = new BedrockDimension(minY, maxY - minY, true, BedrockDimension.OVERWORLD_ID);
            if (isInOverworld) {
                this.bedrockDimension = this.bedrockOverworldDimension;
            }
            geyser.getLogger().debug("Extending overworld dimension to " + minY + " - " + maxY);

            DimensionDataPacket dimensionDataPacket = new DimensionDataPacket();
            dimensionDataPacket.getDefinitions().add(new DimensionDefinition("minecraft:overworld", maxY, minY, 5 /* Void */));
            upstream.sendPacket(dimensionDataPacket);
        }

        startGame();
        sentSpawnPacket = true;
        syncEntityProperties();

        ItemComponentPacket componentPacket = new ItemComponentPacket();
        componentPacket.getItems().addAll(itemMappings.getItemDefinitions().values());
        upstream.sendPacket(componentPacket);

        ChunkUtils.sendEmptyChunks(this, playerEntity.getPosition().toInt(), 0, false);

        BiomeDefinitionListPacket biomeDefinitionListPacket = new BiomeDefinitionListPacket();
        biomeDefinitionListPacket.setBiomes(Registries.BIOMES.get());
        upstream.sendPacket(biomeDefinitionListPacket);

        AvailableEntityIdentifiersPacket entityPacket = new AvailableEntityIdentifiersPacket();
        entityPacket.setIdentifiers(Registries.BEDROCK_ENTITY_IDENTIFIERS.get());
        upstream.sendPacket(entityPacket);

        CameraPresetsPacket cameraPresetsPacket = new CameraPresetsPacket();
        cameraPresetsPacket.getPresets().addAll(CameraDefinitions.CAMERA_PRESETS);
        upstream.sendPacket(cameraPresetsPacket);

        CreativeContentPacket creativePacket = new CreativeContentPacket();
        creativePacket.getContents().addAll(this.itemMappings.getCreativeItems());
        creativePacket.getGroups().addAll(this.itemMappings.getCreativeItemGroups());
        upstream.sendPacket(creativePacket);

        PlayStatusPacket playStatusPacket = new PlayStatusPacket();
        playStatusPacket.setStatus(PlayStatusPacket.Status.PLAYER_SPAWN);
        upstream.sendPacket(playStatusPacket);

        SetCommandsEnabledPacket setCommandsEnabledPacket = new SetCommandsEnabledPacket();
        setCommandsEnabledPacket.setCommandsEnabled(!geyser.config().gameplay().xboxAchievementsEnabled());
        upstream.sendPacket(setCommandsEnabledPacket);

        UpdateAttributesPacket attributesPacket = new UpdateAttributesPacket();
        attributesPacket.setRuntimeEntityId(getPlayerEntity().getGeyserId());
        // Default move speed
        // Bedrock clients move very fast by default until they get an attribute packet correcting the speed
        attributesPacket.setAttributes(Collections.singletonList(
            GeyserAttributeType.MOVEMENT_SPEED.getAttribute()));
        upstream.sendPacket(attributesPacket);

        GameRulesChangedPacket gamerulePacket = new GameRulesChangedPacket();
        // Only allow the server to send health information
        // Setting this to false allows natural regeneration to work false but doesn't break it being true
        gamerulePacket.getGameRules().add(new GameRuleData<>("naturalregeneration", false));
        // Don't let the client modify the inventory on death
        // Setting this to true allows keep inventory to work if enabled but doesn't break functionality being false
        gamerulePacket.getGameRules().add(new GameRuleData<>("keepinventory", true));
        // Ensure client doesn't try and do anything funky; the server handles this for us
        gamerulePacket.getGameRules().add(new GameRuleData<>("spawnradius", 0));
        // Recipe unlocking
        gamerulePacket.getGameRules().add(new GameRuleData<>("recipesunlock", true));
        // We disable the locator bar until we are certain that the server wants us to enable it
        // See WaypointCache for details
        gamerulePacket.getGameRules().add(new GameRuleData<>("locatorBar", false));
        
        upstream.sendPacket(gamerulePacket);
    }

    public void authenticate(String username) {
        if (loggedIn) {
            geyser.getLogger().severe(GeyserLocale.getLocaleStringLog("geyser.auth.already_loggedin", username));
            return;
        }

        loggingIn = true;
        // Always replace spaces with underscores to avoid illegal nicknames, e.g. with GeyserConnect
        protocol = new MinecraftProtocol(username.replace(' ', '_'));

        try {
            connectDownstream();
        } catch (Throwable t) {
            t.printStackTrace();
        }
    }

    public void authenticateWithAuthChain(String authChain) {
        if (loggedIn) {
            geyser.getLogger().severe(GeyserLocale.getLocaleStringLog("geyser.auth.already_loggedin", getAuthData().name()));
            return;
        }

        loggingIn = true;

        CompletableFuture.supplyAsync(() -> {
            JavaAuthManager authManager;
            MinecraftProfile mcProfile;
            MinecraftToken mcToken;
            try {
                JsonObject parsedAuthChain = GeyserImpl.GSON.fromJson(authChain, JsonObject.class);
                if (parsedAuthChain.has("mcProfile")) { // Old Minecraft v4 auth chain
                    parsedAuthChain = MinecraftAuth4To5Migrator.migrateJavaSave(parsedAuthChain, GeyserImpl.OAUTH_CONFIG);
                }

                authManager = JavaAuthManager.fromJson(PendingMicrosoftAuthentication.AUTH_CLIENT, parsedAuthChain);
                mcProfile = authManager.getMinecraftProfile().getUpToDate();
                mcToken = authManager.getMinecraftToken().getUpToDate();
            } catch (Exception e) {
                geyser.getLogger().error("Error while attempting to use auth chain for " + bedrockUsername() + "!", e);
                return Boolean.FALSE;
            }

            protocol = new MinecraftProtocol(
                new GameProfile(mcProfile.getId(), mcProfile.getName()),
                mcToken.getToken()
            );
            geyser.saveAuthChain(bedrockUsername(), GeyserImpl.GSON.toJson(JavaAuthManager.toJson(authManager)));
            return Boolean.TRUE;
        }).whenComplete((successful, ex) -> {
            if (this.closed) {
                return;
            }
            if (successful == Boolean.FALSE) {
                // The player is waiting for a spawn packet, so let's spawn them in now to show them forms
                connect();
                // Will be cached for after login
                LoginEncryptionUtils.buildAndShowTokenExpiredWindow(this);
                return;
            }

            try {
                connectDownstream();
            } catch (Throwable t) {
                t.printStackTrace();
            }
        });
    }

    public void authenticateWithMicrosoftCode() {
        authenticateWithMicrosoftCode(false);
    }

    /**
     * Present a form window to the user asking to log in with another web browser
     */
    public void authenticateWithMicrosoftCode(boolean offlineAccess) {
        if (loggedIn) {
            geyser.getLogger().severe(GeyserLocale.getLocaleStringLog("geyser.auth.already_loggedin", getAuthData().name()));
            return;
        }

        loggingIn = true;

        // This just looks cool
        SetTimePacket packet = new SetTimePacket();
        packet.setTime(16000);
        sendUpstreamPacket(packet);

        final PendingMicrosoftAuthentication.AuthenticationTask task = geyser.getPendingMicrosoftAuthentication().getOrCreateTask(
            getAuthData().xuid()
        );
        if (task.getAuthentication() != null && task.getAuthentication().isDone()) {
            onMicrosoftLoginComplete(task);
        } else {
            task.resetRunningFlow();
            task.performLoginAttempt(offlineAccess, code -> {
                if (!closed) {
                    LoginEncryptionUtils.buildAndShowMicrosoftCodeWindow(this, code);
                }
            }).handle((r, e) -> onMicrosoftLoginComplete(task));
        }
    }

    /**
     * If successful, also begins connecting to the Java server.
     */
    public boolean onMicrosoftLoginComplete(PendingMicrosoftAuthentication.AuthenticationTask task) {
        if (closed) {
            return false;
        }
        task.cleanup(); // player is online -> remove pending authentication immediately
        return task.getAuthentication().handle((result, ex) -> {
            if (ex != null) {
                geyser.getLogger().error("Failed to log in with Microsoft code!", ex);
                if (ex instanceof CompletionException ce && ce.getCause() instanceof MinecraftProfileNotFoundException) {
                    // Player is trying to join with a Microsoft account that doesn't have Java Edition purchased
                    disconnect(GeyserLocale.getPlayerLocaleString("geyser.network.remote.invalid_account", locale()));
                } else {
                    disconnect(ex.toString());
                }
                return false;
            }

            MinecraftProfile mcProfile = result.getMinecraftProfile().getCached();
            MinecraftToken mcToken = result.getMinecraftToken().getCached();

            this.protocol = new MinecraftProtocol(
                new GameProfile(mcProfile.getId(), mcProfile.getName()),
                mcToken.getToken()
            );

            try {
                connectDownstream();
            } catch (Throwable t) {
                t.printStackTrace();
                return false;
            }

            // Save our auth chain for later use
            geyser.saveAuthChain(bedrockUsername(), GeyserImpl.GSON.toJson(JavaAuthManager.toJson(result)));
            return true;
        }).getNow(false);
    }

    /**
     * After getting whatever credentials needed, we attempt to join the Java server.
     */
    private void connectDownstream() {
        SessionLoginEvent loginEvent = new SessionLoginEvent(this, remoteServer, new Object2ObjectOpenHashMap<>());
        GeyserImpl.getInstance().eventBus().fire(loginEvent);
        if (loginEvent.isCancelled()) {
            String disconnectReason = loginEvent.disconnectReason() == null ?
                BedrockDisconnectReasons.DISCONNECTED : loginEvent.disconnectReason();
            disconnect(disconnectReason);
            return;
        }

        this.cookies = loginEvent.cookies();
        // Don't allow changing the remote server when it's not up to us
        // Just imagine the chaos of using an integrated world manager for an external server :)
        this.remoteServer = this.geyser.platformType() == PlatformType.STANDALONE ? loginEvent.remoteServer() : remoteServer;

        // Start ticking
        tickThread = tickEventLoop.scheduleAtFixedRate(this::tick, nanosecondsPerTick, nanosecondsPerTick, TimeUnit.NANOSECONDS);

        ClientSession downstream;
        if (geyser.getBootstrap().getSocketAddress() != null) {
            // We're going to connect through the JVM and not through TCP
            downstream = new LocalSession(geyser.getBootstrap().getSocketAddress(),
                upstream.getAddress().getAddress().getHostAddress(),
                this.protocol, this.tickEventLoop);
            downstream.setFlag(MinecraftConstants.CLIENT_HOST, this.remoteServer.address());
            downstream.setFlag(MinecraftConstants.CLIENT_PORT, this.remoteServer.port());
            this.downstream = new DownstreamSession(downstream);
        } else {
            downstream = new ClientNetworkSession(new InetSocketAddress(this.remoteServer.address(), this.remoteServer.port()), this.protocol, tickEventLoop, null, null);
            this.downstream = new DownstreamSession(downstream);

            boolean resolveSrv = false;
            try {
                resolveSrv = this.remoteServer.resolveSrv();
            } catch (AbstractMethodError | NoSuchMethodError ignored) {
                // Ignore if the method doesn't exist
                // This will happen with extensions using old APIs
            }
            this.downstream.getSession().setFlag(BuiltinFlags.ATTEMPT_SRV_RESOLVE, resolveSrv);
        }

        // Disable automatic creation of a new TcpClientSession when transferring - we don't use that functionality.
        this.downstream.getSession().setFlag(MinecraftConstants.FOLLOW_TRANSFERS, false);

        if (geyser.config().advanced().java().useHaproxyProtocol()) {
            downstream.setFlag(BuiltinFlags.CLIENT_PROXIED_ADDRESS, upstream.getAddress());
        }
        if (geyser.config().gameplay().forwardPlayerPing()) {
            // Let Geyser handle sending the keep alive
            downstream.setFlag(MinecraftConstants.AUTOMATIC_KEEP_ALIVE_MANAGEMENT, false);
        }
        // We'll handle this since we have the registry data on hand
        downstream.setFlag(MinecraftConstants.SEND_BLANK_KNOWN_PACKS_RESPONSE, false);

        // We manually add the default listener to ensure the order of listeners.
        protocol.setUseDefaultListeners(false);

        // MCPL listener comes first to handle protocol state switching before Geyser translates packets
        downstream.addListener(new ClientListener(HandshakeIntent.LOGIN));
        // Geyser adapter second to ensure translating packets in the correct states
        downstream.addListener(new GeyserSessionAdapter(this));

        downstream.setFlag(BuiltinFlags.CLIENT_TRANSFERRING, loginEvent.transferring());
        downstream.connect(false);

        if (!daylightCycle) {
            setDaylightCycle(true);
        }
    }

    public void disconnect(String reason) {
        disconnect(Component.text(reason));
    }

    public void disconnect(Component reason) {
        if (!closed) {
            loggedIn = false;

            SessionDisconnectEvent disconnectEvent = new SessionDisconnectEventImpl(this, reason);
            if (authData != null && clientData != null) { // can occur if player disconnects before Bedrock auth finishes
                // Fire SessionDisconnectEvent
                geyser.getEventBus().fire(disconnectEvent);
            }

            // Disconnect downstream if necessary
            if (downstream != null) {
                // No need to disconnect if already closed
                if (!downstream.isClosed()) {
                    downstream.disconnect(reason);
                }
            } else {
                // Downstream's disconnect will fire an event that prints a log message
                // Otherwise, we print a message here
                String address = geyser.config().logPlayerIpAddresses() ? upstream.getAddress().getAddress().toString() : "<IP address withheld>";
                geyser.getLogger().info(GeyserLocale.getLocaleStringLog("geyser.network.disconnect", address, MessageTranslator.convertMessage(reason)));
            }

            // Disconnect upstream if necessary
            if (!upstream.isClosed()) {
                upstream.disconnect(disconnectEvent.disconnectReason());
            }

            // Remove from session manager
            geyser.getSessionManager().removeSession(this);
            if (authData != null) {
                PendingMicrosoftAuthentication.AuthenticationTask task = geyser.getPendingMicrosoftAuthentication().getTask(authData.xuid());
                if (task != null) {
                    task.resetRunningFlow();
                }
            }
        }

        if (tickThread != null) {
            tickThread.cancel(false);
        }

        // Mark session as closed before cancelling erosion futures
        closed = true;
        erosionHandler.close();
    }

    /**
     * Forcibly closes the upstream session
     */
    public void forciblyCloseUpstream() {
        upstream.forciblyClose();
    }

    /**
     * Moves task to the session event loop if already not in it. Otherwise, the task is automatically ran.
     */
    public void ensureInEventLoop(Runnable runnable) {
        if (tickEventLoop.inEventLoop()) {
            executeRunnable(runnable);
            return;
        }

        executeInEventLoop(runnable);
    }

    /**
     * Executes a task and prints a stack trace if an error occurs.
     */
    public void executeInEventLoop(Runnable runnable) {
        tickEventLoop.execute(() -> executeRunnable(runnable));
    }

    /**
     * Schedules a task and prints a stack trace if an error occurs.
     * <p>
     * The task will not run if the session is closed.
     */
    public ScheduledFuture<?> scheduleInEventLoop(Runnable runnable, long duration, TimeUnit timeUnit) {
        return tickEventLoop.schedule(() -> {
            executeRunnable(() -> {
                if (!closed) {
                    runnable.run();
                }
            });
        }, duration, timeUnit);
    }

    public void updateTickingState(float tickRate, boolean frozen) {
        tickThread.cancel(false);
        this.tickingFrozen = frozen;

        tickRate = MathUtils.clamp(tickRate, 1.0f, 10000.0f);
        millisecondsPerTick = 1000.0f / tickRate;
        nanosecondsPerTick = MathUtils.ceil(1000000000.0f / tickRate);
        tickThread = tickEventLoop.scheduleAtFixedRate(this::tick, nanosecondsPerTick, nanosecondsPerTick, TimeUnit.NANOSECONDS);
    }

    private void executeRunnable(Runnable runnable) {
        try {
            runnable.run();
        } catch (ErosionCancellationException e) {
            geyser.getLogger().debug("Caught ErosionCancellationException");
        } catch (Throwable e) {
            geyser.getLogger().error("Error thrown in " + this.bedrockUsername() + "'s event loop!", e);
        }
    }

    /**
     * Called every Minecraft tick.
     */
    protected void tick() {
        try {
            pistonCache.tick();

            if (worldBorder.isResizing()) {
                worldBorder.resize();
            }

            boolean shouldShowFog = !worldBorder.isWithinWarningBoundaries();
            if (shouldShowFog || worldBorder.isCloseToBorderBoundaries()) {
                // Show particles representing where the world border is
                worldBorder.drawWall();
                // Set the mood
                if (shouldShowFog && !isInWorldBorderWarningArea) {
                    isInWorldBorderWarningArea = true;
                    camera().sendFog("minecraft:fog_crimson_forest");
                }
            }
            if (!shouldShowFog && isInWorldBorderWarningArea) {
                // Clear fog as we are outside the world border now
                camera().removeFog("minecraft:fog_crimson_forest");
                isInWorldBorderWarningArea = false;
            }

            boolean gameShouldUpdate = !tickingFrozen || stepTicks > 0;
            if (stepTicks > 0) {
                --stepTicks;
            }

            Entity vehicle = playerEntity.getVehicle();
            if (vehicle instanceof ClientVehicle clientVehicle && vehicle.isValid()) {
                clientVehicle.getVehicleComponent().tickVehicle();
            }

            for (Tickable entity : entityCache.getTickableEntities()) {
                entity.drawTick();
                if (gameShouldUpdate) {
                    entity.tick();
                }
            }

            if (armAnimationTicks >= 0) {
                // As of 1.18.2 Java Edition, it appears that the swing time is dynamically updated depending on the
                // player's effect status, but the animation can cut short if the duration suddenly decreases
                // (from suddenly no longer having mining fatigue, for example)
                // This math is referenced from Java Edition 1.18.2
                int swingTotalDuration;
                int hasteLevel = Math.max(effectCache.getHaste(), effectCache.getConduitPower());
                if (hasteLevel > 0) {
                    swingTotalDuration = 6 - hasteLevel;
                } else {
                    int miningFatigueLevel = effectCache.getMiningFatigue();
                    if (miningFatigueLevel > 0) {
                        swingTotalDuration = 6 + miningFatigueLevel * 2;
                    } else {
                        swingTotalDuration = 6;
                    }
                }
                if (++armAnimationTicks >= swingTotalDuration) {
                    if (sneaking) {
                        // Attempt to re-activate blocking as our swing animation is up
                        if (attemptToBlock()) {
                            playerEntity.updateBedrockMetadata();
                        }
                    }
                    armAnimationTicks = -1;
                }
            }

            this.bundleCache.tick();
            this.dialogManager.tick();
            this.waypointCache.tick();
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }

        ticks++;
        worldTicks++;
    }

    public void startSneaking(boolean updateMetaData) {
        // Toggle the shield, if there is no ongoing arm animation
        // This matches Bedrock Edition behavior as of 1.18.12
        if (armAnimationTicks < 0) {
            attemptToBlock();
        }

        setSneaking(true, updateMetaData);
    }

    public void stopSneaking(boolean updateMetaData) {
        disableBlocking();
        setSneaking(false, updateMetaData);
    }

    private void setSneaking(boolean sneaking, boolean update) {
        this.sneaking = sneaking;

        playerEntity.setFlag(EntityFlag.SNEAKING, sneaking);
        collisionManager.updateScaffoldingFlags(false);

        if (update) {
            playerEntity.updateBedrockMetadata();
        }

        if (mouseoverEntity != null) {
            // Horses, etc can change their property depending on if you're sneaking
            mouseoverEntity.updateInteractiveTag();
        }
    }

    public void setNoClip(boolean noClip) {
        if (this.noClip == noClip) {
            return;
        }

        this.noClip = noClip;
        this.sendAdventureSettings();
    }

    public void setGameMode(GameMode newGamemode) {
        boolean currentlySpectator = this.gameMode == GameMode.SPECTATOR;
        this.gameMode = newGamemode;
        this.cameraData.handleGameModeChange(currentlySpectator, newGamemode);
    }

    public void setClientData(BedrockClientData data) {
        this.clientData = data;
        this.inputCache.setInputMode(org.cloudburstmc.protocol.bedrock.data.InputMode.values()[data.getCurrentInputMode().ordinal()]);
    }

    /**
     * Same as useItem but always default to useTouchRotation false.
     */
    public void useItem(Hand hand) {
        useItem(hand, false);
    }

    /**
     * Convenience method to reduce amount of duplicate code. Sends ServerboundUseItemPacket.
     */
    public void useItem(Hand hand, boolean useTouchRotation) {
        if (playerEntity.getFlag(EntityFlag.USING_ITEM)) {
            return;
        }

        float yaw = playerEntity.getJavaYaw(), pitch = playerEntity.getPitch();
        if (useTouchRotation) { // Only use touch rotation when we actually needed to, resolve https://github.com/GeyserMC/Geyser/issues/5704
            yaw = playerEntity.getBedrockInteractRotation().getY();
            pitch = playerEntity.getBedrockInteractRotation().getX();
        }

        sendDownstreamGamePacket(new ServerboundUseItemPacket(hand, worldCache.nextPredictionSequence(), yaw, pitch));
    }

    public void releaseItem() {
        // Followed to the Minecraft Protocol specification outlined at wiki.vg
        ServerboundPlayerActionPacket releaseItemPacket = new ServerboundPlayerActionPacket(PlayerAction.RELEASE_USE_ITEM, Vector3i.ZERO,
            Direction.DOWN, 0);
        sendDownstreamGamePacket(releaseItemPacket);
    }

    /**
     * Checks to see if a shield is in either hand to activate blocking. If so, it sets the Bedrock client to display
     * blocking and sends a packet to the Java server.
     */
    private boolean attemptToBlock() {
        // Don't try to block while in scaffolding
        if (playerEntity.isInsideScaffolding()) {
            return false;
        }

        if (playerInventoryHolder.inventory().getItemInHand().is(Items.SHIELD)) {
            useItem(Hand.MAIN_HAND);
        } else if (playerInventoryHolder.inventory().getOffhand().is(Items.SHIELD)) {
            useItem(Hand.OFF_HAND);
        } else {
            // No blocking
            return false;
        }

        playerEntity.setFlag(EntityFlag.BLOCKING, true);
        // Metadata should be updated later
        return true;
    }

    /**
     * Starts ticking the amount of time that the Bedrock client has been swinging their arm, and disables blocking if
     * blocking.
     */
    public void activateArmAnimationTicking() {
        armAnimationTicks = 0;
        if (disableBlocking()) {
            playerEntity.updateBedrockMetadata();
        }
    }

    /**
     * You can't break blocks, attack entities, or use items while driving in a boat
     */
    public boolean isHandsBusy() {
        return playerEntity.getVehicle() instanceof BoatEntity && (steeringRight || steeringLeft);
    }

    /**
     * Indicates to the client to stop blocking and tells the Java server the same.
     */
    private boolean disableBlocking() {
        if (playerEntity.getFlag(EntityFlag.BLOCKING)) {
            ServerboundPlayerActionPacket releaseItemPacket = new ServerboundPlayerActionPacket(PlayerAction.RELEASE_USE_ITEM,
                Vector3i.ZERO, Direction.DOWN, 0);
            sendDownstreamGamePacket(releaseItemPacket);
            playerEntity.setFlag(EntityFlag.BLOCKING, false);
            return true;
        }
        return false;
    }

    public void requestOffhandSwap() {
        ServerboundPlayerActionPacket swapHandsPacket = new ServerboundPlayerActionPacket(PlayerAction.SWAP_HANDS, Vector3i.ZERO,
            Direction.DOWN, 0);
        sendDownstreamGamePacket(swapHandsPacket);
    }

    @Override
    public String name() {
        return playerEntity != null ? javaUsername() : bedrockUsername();
    }

    @Override
    public void sendMessage(@NonNull String message) {
        TextPacket textPacket = new TextPacket();
        textPacket.setPlatformChatId("");
        textPacket.setSourceName("");
        textPacket.setXuid("");
        textPacket.setType(TextPacket.Type.CHAT);
        textPacket.setNeedsTranslation(false);
        textPacket.setMessage(message);

        upstream.sendPacket(textPacket);
    }

    @Override
    public boolean isConsole() {
        return false;
    }

    @Override
    public UUID playerUuid() {
        return javaUuid(); // CommandSource allows nullable
    }

    @Override
    public GeyserSession connection() {
        return this;
    }

    @Override
    public String locale() {
        return clientData != null ? clientData.getLanguageCode() : GeyserLocale.getDefaultLocale();
    }

    @Override
    public boolean hasPermission(String permission) {
        // for Geyser-Standalone, standalone's permission system will handle it.
        // for server platforms, the session will be mapped to a server command sender, and the server's api will be used.
        return geyser.commandRegistry().hasPermission(this, permission);
    }

    /**
     * Sends a chat message to the Java server.
     */
    public void sendChat(String message) {
        sendDownstreamGamePacket(new ServerboundChatPacket(message, Instant.now().toEpochMilli(), 0L, null, 0, new BitSet(), 0));
    }

    /**
     * Sends a command to the Java server.
     */
    public void sendCommandPacket(String command) {
        sendDownstreamGamePacket(new ServerboundChatCommandSignedPacket(command, Instant.now().toEpochMilli(), 0L, Collections.emptyList(), 0, new BitSet(), (byte) 0));
    }

    /**
     * Runs the command through platform specific command registries if applicable
     * else, it sends the command to the server.
     */
    @Override
    public void sendCommand(String command) {
        if (MessageTranslator.isTooLong(command, this)) {
            return;
        }

        if (CommandRegistry.STANDALONE_COMMAND_MANAGER) {
            // try to handle the command within the standalone/viaproxy command manager
            String[] args = command.split(" ");
            if (args.length > 0) {
                String root = args[0];

                CommandRegistry registry = GeyserImpl.getInstance().commandRegistry();
                if (registry.rootCommands().contains(root)) {
                    registry.runCommand(this, command);
                    // don't pass the command to the java server here
                    // will pass it through later if the user lacks permission
                    return;
                }
            }
        }

        this.sendCommandPacket(command);
    }

    @Override
    public @NonNull String joinAddress() {
        String combined = Optional.ofNullable(clientData).orElseThrow().getServerAddress();
        int index = combined.lastIndexOf(":");
        return combined.substring(0, index);
    }

    @Override
    public @Positive int joinPort() {
        String combined = Optional.ofNullable(clientData).orElseThrow().getServerAddress();
        int index = combined.lastIndexOf(":");
        return Integer.parseInt(combined.substring(index + 1));
    }

    @Override
    public void sendSkin(@NonNull UUID player, @NonNull SkinData skinData) {
        Objects.requireNonNull(player, "player uuid must not be null!");
        Objects.requireNonNull(skinData, "skinData must not be null!");

        PlayerEntity entity = this.entityCache.getPlayerEntity(player);
        if (entity == null) {
            return;
        }

        SkinManager.sendSkinPacket(this, entity, skinData);
    }

    @Override
    public void openPauseScreenAdditions() {
        List<Dialog> additions = tagCache.get(DialogTag.PAUSE_SCREEN_ADDITIONS);
        if (additions.isEmpty()) {
            if (!serverLinks.isEmpty()) {
                dialogManager.openDialog(BuiltInDialog.SERVER_LINKS);
            }
        } else if (additions.size() == 1) {
            dialogManager.openDialog(additions.get(0));
        } else {
            dialogManager.openDialog(BuiltInDialog.CUSTOM_OPTIONS);
        }
    }

    @Override
    public void openQuickActions() {
        List<Dialog> quickActions = tagCache.get(DialogTag.QUICK_ACTIONS);
        if (quickActions.isEmpty()) {
            return;
        } else if (quickActions.size() == 1) {
            dialogManager.openDialog(quickActions.get(0));
        } else {
            dialogManager.openDialog(BuiltInDialog.QUICK_ACTIONS);
        }
    }

    public void setClientRenderDistance(int clientRenderDistance) {
        boolean oldSquareToCircle = this.clientRenderDistance < this.serverRenderDistance;
        this.clientRenderDistance = clientRenderDistance;
        boolean newSquareToCircle = this.clientRenderDistance < this.serverRenderDistance;

        if (this.serverRenderDistance != -1 && oldSquareToCircle != newSquareToCircle) {
            recalculateBedrockRenderDistance();
        }
    }

    public void setServerRenderDistance(int renderDistance) {
        // Ensure render distance is not above 96 as sending a larger value at any point crashes mobile clients and 96 is the max of any bedrock platform
        renderDistance = Math.min(renderDistance, 96);
        this.serverRenderDistance = renderDistance;

        recalculateBedrockRenderDistance();
    }

    /**
     * Ensures that the ChunkRadiusUpdatedPacket uses the correct render distance for whatever the client distance is set as.
     * If the server render distance is larger than the client's, then account for this and add some extra padding.
     * We don't want to apply this for every render distance, if at all possible, because
     */
    private void recalculateBedrockRenderDistance() {
        int renderDistance = ChunkUtils.squareToCircle(this.serverRenderDistance);
        ChunkRadiusUpdatedPacket chunkRadiusUpdatedPacket = new ChunkRadiusUpdatedPacket();
        chunkRadiusUpdatedPacket.setRadius(renderDistance);
        upstream.sendPacket(chunkRadiusUpdatedPacket);
    }

    public InetSocketAddress getSocketAddress() {
        return this.upstream.getAddress();
    }

    @Override
    public boolean sendForm(@NonNull Form form) {
        // First close any dialogs that are open. This won't execute the dialog's closing action.
        dialogManager.close();
        // Also close all currently open forms.
        if (formCache.hasFormOpen()) {
            closeForm();
        }

        // Cache this form, let's see whether we can open it immediately
        formCache.addForm(form);

        // Also close current inventories, otherwise the form will not show
        if (inventoryHolder != null) {
            // We'll open the form when the client confirms current inventory being closed
            InventoryUtils.sendJavaContainerClose(inventoryHolder);
            InventoryUtils.closeInventory(this, inventoryHolder, true);
        }

        // Open the current form, unless we're in the process of closing another
        // If we're waiting, the form will be sent when Bedrock confirms closing
        // If we don't wait, the client rejects the form as it is busy
        if (!isClosingInventory() && upstream.isInitialized()) {
            formCache.resendAllForms();
        }

        return true;
    }

    /**
     * Sends a form without first closing any open dialog. This should only be used by {@link org.geysermc.geyser.session.dialog.Dialog}s.
     */
    public void sendDialogForm(@NonNull Form form) {
        doSendForm(form);
    }

    private boolean doSendForm(@NonNull Form form) {
        formCache.showForm(form);
        return true;
    }

    public void acceptCodeOfConduct() {
        if (hasAcceptedCodeOfConduct) {
            return;
        }
        hasAcceptedCodeOfConduct = true;
        sendDownstreamConfigurationPacket(ServerboundAcceptCodeOfConductPacket.INSTANCE);
    }

    public void prepareForConfigurationForm() {
        if (!sentSpawnPacket) {
            connect();
        }
        // Disable time progression whilst the form is open
        // Once logged into the game this is set correctly when receiving a time packet from the server
        setDaylightCycle(false);
    }

    public @NonNull PlayerInventory getPlayerInventory() {
        return this.playerInventoryHolder.inventory();
    }

    public @Nullable Inventory getOpenInventory() {
        if (this.inventoryHolder == null) {
            return null;
        }
        return this.inventoryHolder.inventory();
    }

    @Override
    public boolean sendForm(@NonNull FormBuilder<?, ?, ?> formBuilder) {
        sendForm(formBuilder.build());
        return true;
    }

    /**
     * @deprecated since Cumulus version 1.1, and will be removed when Cumulus 2.0 releases. Please use the new forms instead.
     */
    @Deprecated
    public void sendForm(org.geysermc.cumulus.Form<?> form) {
        sendForm(form.newForm());
    }

    /**
     * @deprecated since Cumulus version 1.1, and will be removed when Cumulus 2.0 releases. Please use the new forms instead.
     */
    @Deprecated
    public void sendForm(org.geysermc.cumulus.util.FormBuilder<?, ?> formBuilder) {
        sendForm(formBuilder.build());
    }

    private void startGame() {
        this.upstream.getCodecHelper().setItemDefinitions(this.itemMappings);
        this.upstream.getCodecHelper().setBlockDefinitions(this.blockMappings);
        this.upstream.getCodecHelper().setCameraPresetDefinitions(CameraDefinitions.CAMERA_DEFINITIONS);

        StartGamePacket startGamePacket = new StartGamePacket();
        startGamePacket.setUniqueEntityId(playerEntity.getGeyserId());
        startGamePacket.setRuntimeEntityId(playerEntity.getGeyserId());
        startGamePacket.setPlayerGameType(EntityUtils.toBedrockGamemode(gameMode));
        startGamePacket.setPlayerPosition(Vector3f.from(0, 69, 0));
        startGamePacket.setRotation(Vector2f.from(1, 1));

        startGamePacket.setSeed(-1L);
        startGamePacket.setDimensionId(bedrockDimension.bedrockId());
        startGamePacket.setGeneratorId(1);
        startGamePacket.setLevelGameType(GameType.SURVIVAL);
        startGamePacket.setDifficulty(1);
        startGamePacket.setDefaultSpawn(Vector3i.ZERO);
        startGamePacket.setAchievementsDisabled(!geyser.config().gameplay().xboxAchievementsEnabled());
        startGamePacket.setCurrentTick(-1);
        startGamePacket.setEduEditionOffers(0);
        startGamePacket.setEduFeaturesEnabled(false);
        startGamePacket.setRainLevel(0);
        startGamePacket.setLightningLevel(0);
        startGamePacket.setMultiplayerGame(true);
        startGamePacket.setBroadcastingToLan(true);
        startGamePacket.setPlatformBroadcastMode(GamePublishSetting.PUBLIC);
        startGamePacket.setXblBroadcastMode(GamePublishSetting.PUBLIC);
        startGamePacket.setCommandsEnabled(!geyser.config().gameplay().xboxAchievementsEnabled());
        startGamePacket.setTexturePacksRequired(false);
        startGamePacket.setBonusChestEnabled(false);
        startGamePacket.setStartingWithMap(false);
        startGamePacket.setTrustingPlayers(true);
        startGamePacket.setDefaultPlayerPermission(PlayerPermission.MEMBER);
        startGamePacket.setServerChunkTickRange(4);
        startGamePacket.setBehaviorPackLocked(false);
        startGamePacket.setResourcePackLocked(false);
        startGamePacket.setFromLockedWorldTemplate(false);
        startGamePacket.setUsingMsaGamertagsOnly(false);
        startGamePacket.setFromWorldTemplate(false);
        startGamePacket.setWorldTemplateOptionLocked(false);
        startGamePacket.setSpawnBiomeType(SpawnBiomeType.DEFAULT);
        startGamePacket.setCustomBiomeName("");
        startGamePacket.setEducationProductionId("");
        startGamePacket.setForceExperimentalGameplay(OptionalBoolean.empty());

        String serverName = geyser.config().gameplay().serverName();
        startGamePacket.setLevelId(serverName);
        startGamePacket.setLevelName(serverName);

        startGamePacket.setPremiumWorldTemplateId("00000000-0000-0000-0000-000000000000");
        // startGamePacket.setCurrentTick(0);
        startGamePacket.setEnchantmentSeed(0);
        startGamePacket.setMultiplayerCorrelationId("");

        startGamePacket.getItemDefinitions().addAll(this.itemMappings.getItemDefinitions().values());

        // Needed for custom block mappings and custom skulls system
        startGamePacket.getBlockProperties().addAll(this.blockMappings.getBlockProperties());

        // See https://learn.microsoft.com/en-us/minecraft/creator/documents/experimentalfeaturestoggle for info on each experiment
        // data_driven_items (Holiday Creator Features) is needed for blocks and items
        startGamePacket.getExperiments().add(new ExperimentData("data_driven_items", true));
        // Needed for block properties for states
        startGamePacket.getExperiments().add(new ExperimentData("upcoming_creator_features", true));
        // Needed for certain molang queries used in blocks and items
        startGamePacket.getExperiments().add(new ExperimentData("experimental_molang_features", true));

        // Enable 2025 Content Drop 3 features on 1.21.100
        if (GameProtocol.is1_21_100(this)) {
            startGamePacket.getExperiments().add(new ExperimentData("y_2025_drop_3", true));
        }

        startGamePacket.setVanillaVersion("*");
        startGamePacket.setInventoriesServerAuthoritative(true);
        startGamePacket.setServerEngine(""); // Do we want to fill this in?

        startGamePacket.setPlayerPropertyData(NbtMap.EMPTY);
        startGamePacket.setWorldTemplateId(UUID.randomUUID());

        startGamePacket.setChatRestrictionLevel(ChatRestrictionLevel.NONE);

        startGamePacket.setAuthoritativeMovementMode(AuthoritativeMovementMode.SERVER);
        startGamePacket.setRewindHistorySize(0);
        // Server authorative block breaking results in the client always sending
        // positions for block breaking actions, which is easier to validate
        // It does *not* mean we can dictate the break speed server-sided :(
        startGamePacket.setServerAuthoritativeBlockBreaking(true);

        if (playerEntity.getPropertyManager() != null) {
            startGamePacket.setPlayerPropertyData(playerEntity.getPropertyManager().toNbtMap("minecraft:player"));
        }

        startGamePacket.setServerId("");
        startGamePacket.setWorldId("");
        startGamePacket.setScenarioId("");
        startGamePacket.setOwnerId("");

        upstream.sendPacket(startGamePacket);
    }

    private void syncEntityProperties() {
        for (NbtMap nbtMap : Registries.BEDROCK_ENTITY_PROPERTIES.get()) {
            SyncEntityPropertyPacket syncEntityPropertyPacket = new SyncEntityPropertyPacket();
            syncEntityPropertyPacket.setData(nbtMap);
            upstream.sendPacket(syncEntityPropertyPacket);
        }
    }

    /**
     * @return the next Bedrock item network ID to use for a new item
     */
    public int getNextItemNetId() {
        return itemNetId.getAndIncrement();
    }

    public void confirmTeleport(Vector3f position) {
        if (unconfirmedTeleport == null) {
            return;
        }

        if (unconfirmedTeleport.canConfirm(position)) {
            unconfirmedTeleport = null;
            return;
        }

        // Resend the teleport every few packets until Bedrock responds
        unconfirmedTeleport.incrementUnconfirmedFor();
        if (unconfirmedTeleport.shouldResend()) {
            unconfirmedTeleport.resetUnconfirmedFor();
            geyser.getLogger().debug("Resending teleport " + unconfirmedTeleport.getTeleportConfirmId());
            getPlayerEntity().moveAbsolute(unconfirmedTeleport.getPosition(),
                unconfirmedTeleport.getYaw(), unconfirmedTeleport.getPitch(), playerEntity.isOnGround(), true);

            if (unconfirmedTeleport.getTeleportType() == TeleportCache.TeleportType.KEEP_VELOCITY) {
                SetEntityMotionPacket entityMotionPacket = new SetEntityMotionPacket();
                entityMotionPacket.setRuntimeEntityId(playerEntity.getGeyserId());
                entityMotionPacket.setMotion(unconfirmedTeleport.getVelocity());
                this.sendUpstreamPacket(entityMotionPacket);
            }
        }
    }

    /**
     * Queue a packet to be sent to player.
     *
     * @param packet the bedrock packet from the Cloudburst protocol lib
     */
    public void sendUpstreamPacket(BedrockPacket packet) {
        upstream.sendPacket(packet);
    }

    /**
     * Send a packet immediately to the player.
     *
     * @param packet the bedrock packet from the Cloudburst protocol lib
     */
    public void sendUpstreamPacketImmediately(BedrockPacket packet) {
        upstream.sendPacketImmediately(packet);
    }

    /**
     * Send a packet to the remote server if in the game state.
     *
     * @param packet the java edition packet from MCProtocolLib
     */
    public void sendDownstreamGamePacket(Packet packet) {
        sendDownstreamPacket(packet, ProtocolState.GAME);
    }

    public void sendDownstreamConfigurationPacket(Packet packet) {
        sendDownstreamPacket(packet, ProtocolState.CONFIGURATION);
    }

    /**
     * Send a packet to the remote server if in the login state.
     *
     * @param packet the java edition packet from MCProtocolLib
     */
    public void sendDownstreamLoginPacket(Packet packet) {
        sendDownstreamPacket(packet, ProtocolState.LOGIN);
    }

    /**
     * Send a packet to the remote server if in the specified state.
     *
     * @param packet the java edition packet from MCProtocolLib
     * @param intendedState the state the client should be in
     */
    public void sendDownstreamPacket(Packet packet, ProtocolState intendedState) {
        // protocol can be null when we're not yet logged in (online auth)
        if (protocol == null) {
            if (geyser.config().debugMode()) {
                geyser.getLogger().debug("Tried to send downstream packet with no downstream session!");
                Thread.dumpStack();
            }
            return;
        }

        if (protocol.getOutboundState() != intendedState) {
            geyser.getLogger().debug("Tried to send " + packet.getClass().getSimpleName() + " packet while not in " + intendedState.name() + " outbound state. Current state: " + protocol.getOutboundState().name());
            return;
        }

        sendDownstreamPacket(packet);
    }

    /**
     * Send a packet to the remote server.
     *
     * @param packet the java edition packet from MCProtocolLib
     */
    public void sendDownstreamPacket(Packet packet) {
        if (!closed && this.downstream != null) {
            Channel channel = this.downstream.getSession().getChannel();
            if (channel == null) {
                // Channel is only null before the connection has initialized
                geyser.getLogger().warning("Tried to send a packet to the Java server too early!");
                if (geyser.config().debugMode()) {
                    Thread.dumpStack();
                }
                return;
            }

            EventLoop eventLoop = channel.eventLoop();
            if (eventLoop.inEventLoop()) {
                sendDownstreamPacket0(packet);
            } else {
                eventLoop.execute(() -> sendDownstreamPacket0(packet));
            }
        }
    }

    private void sendDownstreamPacket0(Packet packet) {
        ProtocolState state = protocol.getOutboundState();
        if (state == ProtocolState.GAME || state == ProtocolState.CONFIGURATION || packet.getClass() == ServerboundCustomQueryAnswerPacket.class) {
            downstream.sendPacket(packet);
        } else {
            geyser.getLogger().debug("Tried to send downstream packet " + packet.getClass().getSimpleName() + " before connected to the server");
        }
    }

    /**
     * Update the cached value for the reduced debug info gamerule.
     * If enabled, also hides the player's coordinates.
     *
     * @param value The new value for reducedDebugInfo
     */
    public void setReducedDebugInfo(boolean value) {
        reducedDebugInfo = value;
        // Set the showCoordinates data. This is done because updateShowCoordinates() uses this gamerule as a variable.
        preferencesCache.updateShowCoordinates();
    }

    /**
     * Changes the daylight cycle gamerule on the client
     * This is used in login and configuration screens along-side normal usage
     *
     * @param doCycle If the cycle should continue
     */
    public void setDaylightCycle(boolean doCycle) {
        sendGameRule("dodaylightcycle", doCycle);
        // Save the value so we don't have to constantly send a daylight cycle gamerule update
        this.daylightCycle = doCycle;
    }

    /**
     * Send a gamerule value to the client
     *
     * @param gameRule The gamerule to send
     * @param value The value of the gamerule
     */
    public void sendGameRule(String gameRule, Object value) {
        GameRulesChangedPacket gameRulesChangedPacket = new GameRulesChangedPacket();
        gameRulesChangedPacket.getGameRules().add(new GameRuleData<>(gameRule, value));
        upstream.sendPacket(gameRulesChangedPacket);
    }

    private static final Ability[] USED_ABILITIES = Ability.values();

    /**
     * Send an AdventureSettingsPacket to the client with the latest flags
     */
    public void sendAdventureSettings() {
        long bedrockId = playerEntity.getGeyserId();
        // Set command permission if OP permission level is high enough
        // This allows mobile players access to a GUI for doing commands. The commands there do not change above OPERATOR
        // and all commands there are accessible with OP permission level 2
        CommandPermission commandPermission = opPermissionLevel >= 2 ? CommandPermission.GAME_DIRECTORS : CommandPermission.ANY;
        // Required to make command blocks destroyable
        PlayerPermission playerPermission = opPermissionLevel >= 2 ? PlayerPermission.OPERATOR : PlayerPermission.MEMBER;

        // Update the noClip and worldImmutable values based on the current gamemode
        boolean spectator = gameMode == GameMode.SPECTATOR;
        boolean worldImmutable = gameMode == GameMode.ADVENTURE || spectator;

        UpdateAdventureSettingsPacket adventureSettingsPacket = new UpdateAdventureSettingsPacket();
        adventureSettingsPacket.setNoMvP(false);
        adventureSettingsPacket.setNoPvM(false);
        adventureSettingsPacket.setImmutableWorld(worldImmutable);
        adventureSettingsPacket.setShowNameTags(false);
        adventureSettingsPacket.setAutoJump(true);
        sendUpstreamPacket(adventureSettingsPacket);

        UpdateAbilitiesPacket updateAbilitiesPacket = new UpdateAbilitiesPacket();
        updateAbilitiesPacket.setUniqueEntityId(bedrockId);
        updateAbilitiesPacket.setCommandPermission(commandPermission);
        updateAbilitiesPacket.setPlayerPermission(playerPermission);

        AbilityLayer abilityLayer = new AbilityLayer();
        Set<Ability> abilities = abilityLayer.getAbilityValues();
        if (canFly) {
            abilities.add(Ability.MAY_FLY);
        }

        // Default stuff we have to fill in
        abilities.add(Ability.BUILD);
        abilities.add(Ability.MINE);
        // Needed so you can drop items
        abilities.add(Ability.DOORS_AND_SWITCHES);
        // Required for lecterns to work (likely started around 1.19.10; confirmed on 1.19.70)
        abilities.add(Ability.OPEN_CONTAINERS);
        if (gameMode == GameMode.CREATIVE) {
            // Needed so the client doesn't attempt to take away items
            abilities.add(Ability.INSTABUILD);
        }

        if (noClip && !spectator) {
            abilities.add(Ability.NO_CLIP);
        }

        if (commandPermission == CommandPermission.GAME_DIRECTORS) {
            // Fixes a bug? since 1.19.11 where the player can change their gamemode in Bedrock settings and
            // a packet is not sent to the server.
            // https://github.com/GeyserMC/Geyser/issues/3191
            abilities.add(Ability.OPERATOR_COMMANDS);
        }

        if (flying || spectator) {
            if (spectator && !flying) {
                // We're "flying locked" in this gamemode
                flying = true;
                ServerboundPlayerAbilitiesPacket abilitiesPacket = new ServerboundPlayerAbilitiesPacket(true);
                sendDownstreamGamePacket(abilitiesPacket);
            }
            abilities.add(Ability.FLYING);
        }

        if (spectator) {
            AbilityLayer spectatorLayer = new AbilityLayer();
            spectatorLayer.setLayerType(AbilityLayer.Type.SPECTATOR);
            // Setting all abilitySet causes the zoom issue... BDS only sends these, so ig we will too
            Set<Ability> abilitySet = spectatorLayer.getAbilitiesSet();
            abilitySet.add(Ability.BUILD);
            abilitySet.add(Ability.MINE);
            abilitySet.add(Ability.DOORS_AND_SWITCHES);
            abilitySet.add(Ability.OPEN_CONTAINERS);
            abilitySet.add(Ability.ATTACK_PLAYERS);
            abilitySet.add(Ability.ATTACK_MOBS);
            abilitySet.add(Ability.INVULNERABLE);
            abilitySet.add(Ability.FLYING);
            abilitySet.add(Ability.MAY_FLY);
            abilitySet.add(Ability.INSTABUILD);
            abilitySet.add(Ability.NO_CLIP);

            Set<Ability> abilityValues = spectatorLayer.getAbilityValues();
            abilityValues.add(Ability.INVULNERABLE);
            abilityValues.add(Ability.FLYING);
            abilityValues.add(Ability.NO_CLIP);

            updateAbilitiesPacket.getAbilityLayers().add(spectatorLayer);
        }

        abilityLayer.setLayerType(AbilityLayer.Type.BASE);
        abilityLayer.setFlySpeed(flySpeed);
        // https://github.com/GeyserMC/Geyser/issues/3139 as of 1.19.10
        abilityLayer.setWalkSpeed(walkSpeed == 0f ? 0.01f : walkSpeed);
        abilityLayer.setVerticalFlySpeed(1.0f);
        Collections.addAll(abilityLayer.getAbilitiesSet(), USED_ABILITIES);

        updateAbilitiesPacket.getAbilityLayers().add(abilityLayer);
        sendUpstreamPacket(updateAbilitiesPacket);
    }

    private int getRenderDistance() {
        if (clientRenderDistance != -1) {
            // The client has sent a render distance
            return clientRenderDistance;
        } else if (serverRenderDistance != -1) {
            // only known once ClientboundLoginPacket is received
            return serverRenderDistance;
        }
        return 2; // unfortunate default until we got more info
    }

    // We need to send our skin parts to the server otherwise java sees us with no hat, jacket etc
    private static final List<SkinPart> SKIN_PARTS = Arrays.asList(SkinPart.values());

    /**
     * Send a packet to the server to indicate client render distance, locale, skin parts, and hand preference.
     */
    public void sendJavaClientSettings() {
        // Locale is lowercase on Java - (https://github.com/GeyserMC/Geyser/issues/5235)
        ServerboundClientInformationPacket clientSettingsPacket = new ServerboundClientInformationPacket(locale().toLowerCase(Locale.ROOT),
            getRenderDistance(), ChatVisibility.FULL, true, SKIN_PARTS,
            HandPreference.RIGHT_HAND, false, true, ParticleStatus.ALL); // TODO particle status
        sendDownstreamPacket(clientSettingsPacket);
    }

    /**
     * Used for updating statistic values since we only get changes from the server
     *
     * @param statistics Updated statistics values
     */
    public void updateStatistics(@NonNull Object2IntMap<Statistic> statistics) {
        if (this.statistics.isEmpty()) {
            // Initialize custom statistics to 0, so that they appear in the form
            for (CustomStatistic customStatistic : CustomStatistic.values()) {
                this.statistics.put(customStatistic, 0);
            }
        }
        this.statistics.putAll(statistics);
    }

    public void refreshEmotes(List<UUID> emotes) {
        this.emotes.addAll(emotes);
        for (GeyserSession player : geyser.getSessionManager().getSessions().values()) {
            List<UUID> pieces = new ArrayList<>();
            for (UUID piece : emotes) {
                if (!player.getEmotes().contains(piece)) {
                    pieces.add(piece);
                }
                player.getEmotes().add(piece);
            }
            EmoteListPacket emoteList = new EmoteListPacket();
            emoteList.setRuntimeEntityId(player.getPlayerEntity().getGeyserId());
            emoteList.getPieceIds().addAll(pieces);
            player.sendUpstreamPacket(emoteList);
        }
    }

    public boolean canUseCommandBlocks() {
        return instabuild && opPermissionLevel >= 2;
    }

    public void playSoundEvent(SoundEvent sound, Vector3f position) {
        LevelSoundEventPacket packet = new LevelSoundEventPacket();
        packet.setPosition(position);
        packet.setSound(sound);
        packet.setIdentifier(":");
        packet.setExtraData(-1);
        sendUpstreamPacket(packet);
    }

    public float getEyeHeight() {
        return switch (this.pose) {
            case SNEAKING -> 1.27f;
            case SWIMMING,
                 FALL_FLYING, // Elytra
                 SPIN_ATTACK -> 0.4f; // Trident spin attack
            case SLEEPING -> 0.2f;
            default -> EntityDefinitions.PLAYER.offset(); // 1.62F
        };
    }

    /**
     * Sends a packet to update rain strength.
     * Stops rain if strength is 0.
     *
     * @param strength value between 0 and 1
     */
    public void updateRain(float strength) {
        boolean wasRaining = isRaining();
        this.rainStrength = strength;

        LevelEventPacket rainPacket = new LevelEventPacket();
        rainPacket.setType(isRaining() ? LevelEvent.START_RAINING : LevelEvent.STOP_RAINING);
        rainPacket.setData((int) (strength * 65535));
        rainPacket.setPosition(Vector3f.ZERO);
        sendUpstreamPacket(rainPacket);

        // Keep thunder in sync with rain when starting/stopping a storm
        if ((wasRaining != isRaining()) && isThunder()) {
            if (isRaining()) {
                LevelEventPacket thunderPacket = new LevelEventPacket();
                thunderPacket.setType(LevelEvent.START_THUNDERSTORM);
                thunderPacket.setData((int) (this.thunderStrength * 65535));
                thunderPacket.setPosition(Vector3f.ZERO);
                sendUpstreamPacket(thunderPacket);
            } else {
                LevelEventPacket thunderPacket = new LevelEventPacket();
                thunderPacket.setType(LevelEvent.STOP_THUNDERSTORM);
                thunderPacket.setData(0);
                thunderPacket.setPosition(Vector3f.ZERO);
                sendUpstreamPacket(thunderPacket);
            }
        }
    }

    /**
     * Sends a packet to update thunderstorm strength.
     * Stops thunderstorm if strength is 0.
     *
     * @param strength value between 0 and 1
     */
    public void updateThunder(float strength) {
        this.thunderStrength = strength;

        // Do not send thunder packet if not raining
        // The bedrock client will start raining automatically when updating thunder strength
        // https://github.com/GeyserMC/Geyser/issues/3679
        if (!isRaining()) {
            return;
        }

        LevelEventPacket thunderPacket = new LevelEventPacket();
        thunderPacket.setType(isThunder() ? LevelEvent.START_THUNDERSTORM : LevelEvent.STOP_THUNDERSTORM);
        thunderPacket.setData((int) (strength * 65535));
        thunderPacket.setPosition(Vector3f.ZERO);
        sendUpstreamPacket(thunderPacket);
    }

    public boolean isRaining() {
        return this.rainStrength > 0;
    }

    public boolean isThunder() {
        return this.thunderStrength > 0;
    }

    @Override
    public @NonNull String bedrockUsername() {
        return authData.name();
    }

    @Override
    public @MonotonicNonNull String javaUsername() {
        return playerEntity != null ? playerEntity.getUsername() : null;
    }

    @Override
    public UUID javaUuid() {
        return playerEntity != null ? playerEntity.getUuid() : null;
    }

    @Override
    public @NonNull String xuid() {
        return authData.xuid();
    }

    @Override
    public @NonNull String version() {
        if (clientData == null) {
            return "unknown";
        }
        return clientData.getGameVersion();
    }

    @Override
    public @NonNull BedrockPlatform platform() {
        if (clientData == null) {
            return BedrockPlatform.UNKNOWN;
        }
        return BedrockPlatform.values()[clientData.getDeviceOs().ordinal()]; //todo
    }

    @Override
    public @NonNull String languageCode() {
        return locale();
    }

    @Override
    public @NonNull UiProfile uiProfile() {
        return UiProfile.values()[clientData.getUiProfile().ordinal()]; //todo
    }

    @Override
    public @NonNull InputMode inputMode() {
        return InputMode.values()[inputCache.getInputMode().ordinal()]; //todo
    }

    @Override
    public boolean isLinked() {
        return false; //todo
    }

    @SuppressWarnings("ConstantConditions") // Need to enforce the parameter annotations
    @Override
    public boolean transfer(@NonNull String address, @IntRange(from = 0, to = 65535) int port) {
        if (address == null || address.isBlank()) {
            throw new IllegalArgumentException("Server address cannot be null or blank");
        } else if (port < 0 || port > 65535) {
            throw new IllegalArgumentException("Server port must be between 0 and 65535, was " + port);
        }
        TransferPacket transferPacket = new TransferPacket();
        transferPacket.setAddress(address);
        transferPacket.setPort(port);
        sendUpstreamPacket(transferPacket);
        return true;
    }

    @Override
    public @NonNull CompletableFuture<@Nullable GeyserEntity> entityByJavaId(@NonNegative int javaId) {
        return entities().entityByJavaId(javaId);
    }

    @Override
    public void showEmote(@NonNull GeyserPlayerEntity emoter, @NonNull String emoteId) {
        entities().showEmote(emoter, emoteId);
    }

    public void setLockInput(InputLocksFlag flag, boolean value) {
        this.inputLockDirty |= value ? this.inputLocksSet.add(flag) : this.inputLocksSet.remove(flag);
    }

    public void updateInputLocks() {
        if (!this.inputLockDirty) {
            return;
        }
        this.inputLockDirty = false;

        UpdateClientInputLocksPacket packet = new UpdateClientInputLocksPacket();

        int result = 0;
        for (InputLocksFlag other : this.inputLocksSet) {
            result |= other.getOffset();
        }

        packet.setLockComponentData(result);
        packet.setServerPosition(this.playerEntity.getPosition());

        sendUpstreamPacket(packet);
    }

    public boolean getLockedInput(InputLocksFlag flag) {
        return this.inputLocksSet.contains(flag);
    }

    @Override
    public @NonNull CameraData camera() {
        return this.cameraData;
    }

    @Override
    public @NonNull EntityData entities() {
        return this.entityData;
    }

    @Override
    public void shakeCamera(float intensity, float duration, @NonNull CameraShake type) {
        this.cameraData.shakeCamera(intensity, duration, type);
    }

    @Override
    public void stopCameraShake() {
        this.cameraData.stopCameraShake();
    }

    @Override
    public void sendFog(String... fogNameSpaces) {
        this.cameraData.sendFog(fogNameSpaces);
    }

    @Override
    public void removeFog(String... fogNameSpaces) {
        this.cameraData.removeFog(fogNameSpaces);
    }

    @Override
    public @NonNull Set<String> fogEffects() {
        return this.cameraData.fogEffects();
    }

    @Override
    public int ping() {
        // Can otherwise cause issues if the player isn't logged in yet / already left
        if (!getUpstream().isInitialized() || getUpstream().isClosed()) {
            return 0;
        }

        RakSessionCodec rakSessionCodec = ((RakChildChannel) getUpstream().getSession().getPeer().getChannel()).rakPipeline().get(RakSessionCodec.class);
        return (int) Math.floor(rakSessionCodec.getPing());
    }

    @Override
    public int protocolVersion() {
        return upstream.getProtocolVersion();
    }

    @Override
    public boolean hasFormOpen() {
        return formCache.hasFormOpen();
    }

    @Override
    public void closeForm() {
        formCache.closeForms();
    }

    public void addCommandEnum(String name, String enums) {
        softEnumPacket(name, SoftEnumUpdateType.ADD, enums);
    }

    public void removeCommandEnum(String name, String enums) {
        softEnumPacket(name, SoftEnumUpdateType.REMOVE, enums);
    }

    private void softEnumPacket(String name, SoftEnumUpdateType type, String enums) {
        // There is no need to send command enums if command suggestions are disabled
        if (!this.geyser.config().gameplay().commandSuggestions()) {
            return;
        }
        UpdateSoftEnumPacket packet = new UpdateSoftEnumPacket();
        packet.setType(type);
        packet.setSoftEnum(new CommandEnumData(name, Collections.singletonMap(enums, Collections.emptySet()), true));
        sendUpstreamPacket(packet);
    }

    public String getDebugInfo() {
        return "Username: %s, DeviceOs: %s, Version: %s".formatted(bedrockUsername(), platform(), version());
    }
}
